import type { RubyJsJsRuntime } from "./bindgen/interfaces/ruby-js-js-runtime.js";
import type { RubyJsRubyRuntime } from "./bindgen/interfaces/ruby-js-ruby-runtime.js";
import * as RbAbi from "./bindgen/legacy/rb-abi-guest.js";
import {
  RbJsAbiHost,
  addRbJsAbiHostToImports,
  JsAbiResult,
  JsAbiValue,
} from "./bindgen/legacy/rb-js-abi-host.js";
import { Binding, ComponentBinding, LegacyBinding, RbAbiValue } from "./binding.js";

/**
 * A function type that instantiates a Ruby component
 */
export type RubyComponentInstantiator = (
  (
    getCoreModule: (path: string) => WebAssembly.Module,
    importObject: any,
    instantiateCore?: (module: WebAssembly.Module, imports: Record<string, any>) => WebAssembly.Instance | Promise<WebAssembly.Instance>,
  ) => Promise<({ rubyRuntime: typeof RubyJsRubyRuntime })>
)

export type RubyInitComponentOptions = {
  /**
   * A lower-level instantiation function that instantiates the Ruby component with the given component
   * that implements "ruby:js/js-runtime" WIT interface.
   */
  instantiate: (_: typeof RubyJsJsRuntime) => Promise<typeof RubyJsRubyRuntime>;

  /**
   * The arguments to pass to the Ruby VM. Note that the first argument must be the Ruby program name.
   *
   * @default ["ruby.wasm", "-EUTF-8", "-e_=0"]
   */
  args?: string[];
} | {
  /**
   * An `instantiate` function generated by `@bytecodealliance/jco` that instantiates the Ruby component.
   */
  instantiate: RubyComponentInstantiator,

  /**
   * A function that returns a WebAssembly Core module within the Ruby component transpiled by `@bytecodealliance/jco`.
   */
  getCoreModule: (path: string) => Promise<WebAssembly.Module>,

  /**
   * An optional function used to instantiate a WebAssembly Core module
   */
  instantiateCore?: (module: WebAssembly.Module, imports: Record<string, any>) => WebAssembly.Instance | Promise<WebAssembly.Instance>

  /**
   * WASI Preview 2 implementation, typically imported from `import * as wasip2 from "@bytecodealliance/preview2-shim"`
   */
  wasip2: any;

  /**
   * The arguments to pass to the Ruby VM. Note that the first argument must be the Ruby program name.
   *
   * @default ["ruby.wasm", "-EUTF-8", "-e_=0"]
   */
  args?: string[];
}

export type RubyInitModuleOptions = {
  /**
   * The WebAssembly module that contains the Ruby VM
   */
  module: WebAssembly.Module;
  /**
   * WASI Preview 1 implementation supporting reactor model ABI
   */
  wasip1: {
    wasiImport: WebAssembly.ModuleImports;
    initialize(instance: WebAssembly.Instance): void;
  };
  /**
   * The arguments to pass to the Ruby VM. Note that the first argument must be the Ruby program name.
   * @default ["ruby.wasm", "-EUTF-8", "-e_=0"]
   */
  args?: string[];
  /**
   * A hook to add additional imports to the WebAssembly instance
   */
  addToImports?: (imports: WebAssembly.Imports) => void;
  /**
   * A hook called with the WebAssembly memory instance just after the Ruby VM is instantiated
   */
  setMemory?: (memory: WebAssembly.Memory) => void;
}

export type RubyInitOptions = RubyInitComponentOptions | RubyInitModuleOptions;

/**
 * A Ruby VM instance
 * @see {@link RubyVM.instantiateComponent} and {@link RubyVM.instantiateModule} to create a new instance
 * @category Essentials
 */
export class RubyVM {
  /**
   * @private Only for internal use.
   */
  guest: Binding;
  private instance: WebAssembly.Instance | null = null;
  private transport: JsValueTransport;
  private exceptionFormatter: RbExceptionFormatter;
  private interfaceState: RbAbiInterfaceState = {
    hasJSFrameAfterRbFrame: false,
  };

  /**
   * Instantiate a Ruby VM with the given WebAssembly Core module with WASI Preview 1 implementation.
   *
   * @param options The options to instantiate the Ruby VM
   * @returns A promise that resolves to the Ruby VM instance and the WebAssembly instance
   * @category Essentials
   *
   * @example
   *
   * import { WASI } from "@bjorn3/browser_wasi_shim";
   * const wasip1 = new WASI([], [], []);
   * const module = await WebAssembly.compile("./path/to/ruby.wasm");
   * const { vm } = await RubyVM.instantiateModule({ module, wasip1 });
   *
   */
  static async instantiateModule(options: RubyInitModuleOptions): Promise<{ vm: RubyVM, instance: WebAssembly.Instance }> {
    const { module, wasip1 } = options;
    const vm = new RubyVM();
    const imports: WebAssembly.Imports = {
      wasi_snapshot_preview1: wasip1.wasiImport,
    };
    vm.addToImports(imports);
    options.addToImports?.(imports);
    const instance = await WebAssembly.instantiate(module, imports);
    try {
      await vm.setInstance(instance);
    } catch (e) {
      console.error("Failed to instantiate Ruby VM. Please make sure that you have added `gem \"js\"` to your Gemfile.");
      throw e;
    }
    options.setMemory?.(instance.exports.memory as WebAssembly.Memory);
    wasip1.initialize(instance);
    vm.initialize(options.args);
    return { vm, instance };
  }

  /**
   * Instantiate a Ruby VM with the given WebAssembly component with WASI Preview 2 implementation.
   *
   * @param options The options to instantiate the Ruby VM
   * @returns A promise that resolves to the Ruby VM instance
   * @category Essentials
   *
   * @example
   *
   * // First, you need to transpile the Ruby component to a JavaScript module using jco.
   * // $ jco transpile --no-wasi-shim --instantiation --valid-lifting-optimization ./ruby.component.wasm -o ./component
   * // Then, you can instantiate the Ruby VM with the component:
   *
   * import * as wasip2 from "@bytecodealliance/preview2-shim"
   * import fs from "fs/promises";
   * import path from "path";
   *
   * const { instantiate } = await import("./component/ruby.component.js");
   * const getCoreModule = async (relativePath) => {
   *   const buffer = await fs.readFile(path.join("./component", relativePath));
   *   return WebAssembly.compile(buffer);
   * }
   *
   * const { vm } = await RubyVM.instantiateComponent({
   *   instantiate, getCoreModule, wasip2,
   * });
   *
   */
  static async instantiateComponent(options: RubyInitComponentOptions): Promise<{ vm: RubyVM }> {
    let initComponent: (_: typeof RubyJsJsRuntime) => Promise<typeof RubyJsRubyRuntime>;
    if ("getCoreModule" in options) {
      // A convenience overload to instantiate with "instantiate" function generated by jco
      initComponent = async (jsRuntime) => {
        const { instantiate, getCoreModule, wasip2 } = options;
        const { cli, clocks, filesystem, io, random, sockets, http } = wasip2;
        const importObject = {
          "ruby:js/js-runtime": jsRuntime,
          "wasi:cli/environment": cli.environment,
          "wasi:cli/exit": cli.exit,
          "wasi:cli/stderr": cli.stderr,
          "wasi:cli/stdin": cli.stdin,
          "wasi:cli/stdout": cli.stdout,
          "wasi:cli/terminal-input": cli.terminalInput,
          "wasi:cli/terminal-output": cli.terminalOutput,
          "wasi:cli/terminal-stderr": cli.terminalStderr,
          "wasi:cli/terminal-stdin": cli.terminalStdin,
          "wasi:cli/terminal-stdout": cli.terminalStdout,
          "wasi:clocks/monotonic-clock": clocks.monotonicClock,
          "wasi:clocks/wall-clock": clocks.wallClock,
          "wasi:filesystem/preopens": filesystem.preopens,
          "wasi:filesystem/types": filesystem.types,
          "wasi:io/error": io.error,
          "wasi:io/poll": io.poll,
          "wasi:io/streams": io.streams,
          "wasi:random/random": random.random,
          "wasi:sockets/tcp": sockets.tcp,
          "wasi:http/types": http.types,
          "wasi:http/incoming-handler": http.incomingHandler,
          "wasi:http/outgoing-handler": http.outgoingHandler,
        };
        const component = await instantiate(getCoreModule, importObject, options.instantiateCore);
        return component.rubyRuntime;
      }
    } else {
      initComponent = options.instantiate;
    }
    const vm = await this._instantiate({}, initComponent);
    return { vm };
  }

  /**
   * Create a new Ruby VM instance with low-level initialization control.
   *
   * @category Low-level initialization
   * @example
   *
   * ```javascript
   * const wasi = new WASI();
   * const vm = new RubyVM();
   * const imports = {
   *   wasi_snapshot_preview1: wasi.wasiImport,
   * };
   *
   * vm.addToImports(imports);
   *
   * const instance = await WebAssembly.instantiate(rubyModule, imports);
   * await vm.setInstance(instance);
   * wasi.initialize(instance);
   * vm.initialize();
   * ```
   *
   */
  constructor();

  /**
   * @private Only for internal use.
   */
  constructor(binding?: Binding);

  constructor(binding?: Binding) {
    // Wrap exported functions from Ruby VM to prohibit nested VM operation
    // if the call stack has sandwitched JS frames like JS -> Ruby -> JS -> Ruby.
    const proxyExports = (exports: Binding) => {
      const excludedMethods: (keyof LegacyBinding | keyof Binding)[] = [
        "setInstance",
        "addToImports",
        "instantiate",
        "rbSetShouldProhibitRewind",
        "rbGcDisable",
        "rbGcEnable",
      ];
      const excluded = ["constructor"].concat(excludedMethods);
      // wrap all methods in RbAbi.RbAbiGuest class
      for (const key of Object.getOwnPropertyNames(
        RbAbi.RbAbiGuest.prototype,
      )) {
        if (excluded.includes(key)) {
          continue;
        }
        const value = exports[key];
        if (typeof value === "function") {
          exports[key] = (...args: any[]) => {
            const isNestedVMCall = this.interfaceState.hasJSFrameAfterRbFrame;
            if (isNestedVMCall) {
              const oldShouldProhibitRewind =
                this.guest.rbSetShouldProhibitRewind(true);
              const oldIsDisabledGc = this.guest.rbGcDisable();
              const result = Reflect.apply(value, exports, args);
              this.guest.rbSetShouldProhibitRewind(oldShouldProhibitRewind);
              if (!oldIsDisabledGc) {
                this.guest.rbGcEnable();
              }
              return result;
            } else {
              return Reflect.apply(value, exports, args);
            }
          };
        }
      }
      return exports;
    };
    this.guest = proxyExports(binding ?? new LegacyBinding());
    this.transport = new JsValueTransport();
    this.exceptionFormatter = new RbExceptionFormatter();
  }

  private static async _instantiate(
    options: { args?: string[] },
    initComponent: (_: typeof RubyJsJsRuntime) => Promise<typeof RubyJsRubyRuntime>
  ): Promise<RubyVM> {
    const binding = new ComponentBinding()
    const vm = new RubyVM(binding);
    class JsAbiValue {
      constructor(public readonly underlying: any) {}
    }
    const imports = vm.getImports((from) => new JsAbiValue(from), (to) => to.underlying);
    const component = await initComponent({
      ...imports,
      throwProhibitRewindException: (message: string) => {
        vm.throwProhibitRewindException(message);
      },
      procToJsFunction: () => {
        const rbValue = new RbValue(component.exportRbValueToJs(), vm, vm.privateObject());
        return new JsAbiValue((...args) => {
          return rbValue.call("call", ...args.map((arg) => vm.wrap(arg))).toJS();
        });
      },
      rbObjectToJsRbValue: () => {
        const rbValue = new RbValue(component.exportRbValueToJs(), vm, vm.privateObject());
        return new JsAbiValue(rbValue);
      },
      JsAbiValue: JsAbiValue as any,
    });
    binding.setUnderlying(component);
    vm.initialize(options.args);
    return vm
  }

  /**
   * Initialize the Ruby VM with the given command line arguments
   * @param args The command line arguments to pass to Ruby. Must be
   * an array of strings starting with the Ruby program name.
   * @category Low-level initialization
   */
  initialize(args: string[] = ["ruby.wasm", "-EUTF-8", "-e_=0"]) {
    const c_args = args.map((arg) => arg + "\0");
    this.guest.rubyInit(c_args);
    try {
      this.eval(`
        # Require Bundler standalone setup
        if File.exist?("/bundle/bundler/setup.rb")
          require "/bundle/bundler/setup.rb"
        elsif File.exist?("/bundle/setup.rb")
          # For non-CM builds, which doesn't use Bundler's standalone mode
          require "/bundle/setup.rb"
        end
      `);
    } catch (e) {
      console.warn("Failed to load /bundle/setup", e);
    }
  }

  /**
   * Set a given instance to interact JavaScript and Ruby's
   * WebAssembly instance. This method must be called before calling
   * Ruby API.
   *
   * @param instance The WebAssembly instance to interact with. Must
   * be instantiated from a Ruby built with JS extension, and built
   * with Reactor ABI instead of command line.
   * @category Low-level initialization
   */
  async setInstance(instance: WebAssembly.Instance) {
    this.instance = instance;
    await this.guest.setInstance(instance);
  }

  /**
   * Add intrinsic import entries, which is necessary to interact JavaScript
   * and Ruby's WebAssembly instance.
   * @param imports The import object to add to the WebAssembly instance
   * @category Low-level initialization
   */
  addToImports(imports: WebAssembly.Imports) {
    this.guest.addToImports(imports);
    imports["rb-js-abi-host"] = {
      rb_wasm_throw_prohibit_rewind_exception: (
        messagePtr: number,
        messageLen: number,
      ) => {
        const memory = this.instance.exports.memory as WebAssembly.Memory;
        const str = new TextDecoder().decode(
          new Uint8Array(memory.buffer, messagePtr, messageLen),
        );
        this.throwProhibitRewindException(str);
      },
    };

    addRbJsAbiHostToImports(
      imports,
      this.getImports((value) => value, (value) => value),
      (name) => {
        return this.instance.exports[name];
      },
    );
  }

  private throwProhibitRewindException(str: string) {
    let message = "Ruby APIs that may rewind the VM stack are prohibited under nested VM operation " +
        `(${str})\n` +
        "Nested VM operation means that the call stack has sandwitched JS frames like JS -> Ruby -> JS -> Ruby " +
        "caused by something like `window.rubyVM.eval(\"JS.global[:rubyVM].eval('Fiber.yield')\")`\n" +
        "\n" +
        "Please check your call stack and make sure that you are **not** doing any of the following inside the nested Ruby frame:\n" +
        "  1. Switching fibers (e.g. Fiber#resume, Fiber.yield, and Fiber#transfer)\n" +
        "     Note that `evalAsync` JS API switches fibers internally\n" +
        "  2. Raising uncaught exceptions\n" +
        "     Please catch all exceptions inside the nested operation\n" +
        "  3. Calling Continuation APIs\n";

    const error = new RbValue(this.guest.rbErrinfo(), this, this.privateObject());
    if (error.call("nil?").toString() === "false") {
      message += "\n" + this.exceptionFormatter.format(error, this, this.privateObject());
    }
    throw new RbFatalError(message);
  }

  private getImports(toJSAbiValue: (_: any) => any, fromJSAbiValue: (_: any) => any): RbJsAbiHost {
    // NOTE: The GC may collect objects that are still referenced by Wasm
    // locals because Asyncify cannot scan the Wasm stack above the JS frame.
    // So we need to keep track whether the JS frame is sandwitched by Ruby
    // frames or not, and prohibit nested VM operation if it is.
    const proxyImports = (imports: RbJsAbiHost) => {
      for (const [key, value] of Object.entries(imports)) {
        if (typeof value === "function") {
          imports[key] = (...args: any[]) => {
            const oldValue = this.interfaceState.hasJSFrameAfterRbFrame;
            this.interfaceState.hasJSFrameAfterRbFrame = true;
            const result = Reflect.apply(value, imports, args);
            this.interfaceState.hasJSFrameAfterRbFrame = oldValue;
            return result;
          };
        }
      }
      return imports;
    };
    function wrapTry(f: (...args: any[]) => JsAbiValue): () => JsAbiResult {
      return (...args) => {
        try {
          return { tag: "success", val: f(...args) };
        } catch (e) {
          if (e instanceof RbFatalError) {
            // RbFatalError should not be caught by Ruby because it Ruby VM
            // can be already in an inconsistent state.
            throw e;
          }
          return { tag: "failure", val: toJSAbiValue(e) };
        }
      };
    }
    return proxyImports({
      evalJs: wrapTry((code) => {
        return toJSAbiValue(Function(code)());
      }),
      isJs: (value) => {
        // Just for compatibility with the old JS API
        return true;
      },
      globalThis: () => {
        if (typeof globalThis !== "undefined") {
          return toJSAbiValue(globalThis);
        } else if (typeof global !== "undefined") {
          return toJSAbiValue(global);
        } else if (typeof window !== "undefined") {
          return toJSAbiValue(window);
        }
        throw new Error("unable to locate global object");
      },
      intToJsNumber: (value) => {
        return toJSAbiValue(value);
      },
      floatToJsNumber: (value) => {
        return toJSAbiValue(value);
      },
      stringToJsString: (value) => {
        return toJSAbiValue(value);
      },
      boolToJsBool: (value) => {
        return toJSAbiValue(value);
      },
      procToJsFunction: (rawRbAbiValue) => {
        const rbValue = this.rbValueOfPointer(rawRbAbiValue);
        return toJSAbiValue((...args) => {
          return rbValue.call("call", ...args.map((arg) => this.wrap(arg))).toJS();
        });
      },
      rbObjectToJsRbValue: (rawRbAbiValue) => {
        return toJSAbiValue(this.rbValueOfPointer(rawRbAbiValue));
      },
      jsValueToString: (value) => {
        value = fromJSAbiValue(value)
        // According to the [spec](https://tc39.es/ecma262/multipage/text-processing.html#sec-string-constructor-string-value)
        // `String(value)` always returns a string.
        return String(value);
      },
      jsValueToInteger(value) {
        value = fromJSAbiValue(value)
        if (typeof value === "number") {
          return { tag: "as-float", val: value };
        } else if (typeof value === "bigint") {
          return { tag: "bignum", val: BigInt(value).toString(10) + "\0" };
        } else if (typeof value === "string") {
          return { tag: "bignum", val: value + "\0" };
        } else if (typeof value === "undefined") {
          return { tag: "as-float", val: 0 };
        } else {
          return { tag: "as-float", val: Number(value) };
        }
      },
      exportJsValueToHost: (value) => {
        // See `JsValueExporter` for the reason why we need to do this
        this.transport.takeJsValue(fromJSAbiValue(value));
      },
      importJsValueFromHost: () => {
        return toJSAbiValue(this.transport.consumeJsValue());
      },
      instanceOf: (value, klass) => {
        klass = fromJSAbiValue(klass);
        if (typeof klass === "function") {
          return fromJSAbiValue(value) instanceof klass;
        } else {
          return false;
        }
      },
      jsValueTypeof(value) {
        return typeof fromJSAbiValue(value);
      },
      jsValueEqual(lhs, rhs) {
        return fromJSAbiValue(lhs) == fromJSAbiValue(rhs);
      },
      jsValueStrictlyEqual(lhs, rhs) {
        return fromJSAbiValue(lhs) === fromJSAbiValue(rhs);
      },
      reflectApply: wrapTry((target, thisArgument, args) => {
        const jsArgs = args.map((arg) => fromJSAbiValue(arg));
        return toJSAbiValue(Reflect.apply(fromJSAbiValue(target as any), fromJSAbiValue(thisArgument), jsArgs));
      }),
      reflectConstruct: function (target, args) {
        throw new Error("Function not implemented.");
      },
      reflectDeleteProperty: function (target, propertyKey): boolean {
        throw new Error("Function not implemented.");
      },
      reflectGet: wrapTry((target, propertyKey) => {
        return toJSAbiValue(fromJSAbiValue(target)[propertyKey]);
      }),
      reflectGetOwnPropertyDescriptor: function (
        target,
        propertyKey: string,
      ) {
        throw new Error("Function not implemented.");
      },
      reflectGetPrototypeOf: function (target) {
        throw new Error("Function not implemented.");
      },
      reflectHas: function (target, propertyKey): boolean {
        throw new Error("Function not implemented.");
      },
      reflectIsExtensible: function (target): boolean {
        throw new Error("Function not implemented.");
      },
      reflectOwnKeys: function (target) {
        throw new Error("Function not implemented.");
      },
      reflectPreventExtensions: function (target): boolean {
        throw new Error("Function not implemented.");
      },
      reflectSet: wrapTry((target, propertyKey, value) => {
        return toJSAbiValue(Reflect.set(fromJSAbiValue(target), propertyKey, fromJSAbiValue(value)));
      }),
      reflectSetPrototypeOf: function (target, prototype): boolean {
        throw new Error("Function not implemented.");
      },
    })
  }

  /**
   * Print the Ruby version to stdout
   */
  printVersion() {
    this.guest.rubyShowVersion();
  }

  /**
   * Runs a string of Ruby code from JavaScript
   * @param code The Ruby code to run
   * @returns the result of the last expression
   * @category Essentials
   *
   * @example
   * vm.eval("puts 'hello world'");
   * const result = vm.eval("1 + 2");
   * console.log(result.toString()); // 3
   *
   */
  eval(code: string): RbValue {
    return evalRbCode(this, this.privateObject(), code);
  }

  /**
   * Runs a string of Ruby code with top-level `JS::Object#await`
   * Returns a promise that resolves when execution completes.
   * @param code The Ruby code to run
   * @returns a promise that resolves to the result of the last expression
   * @category Essentials
   *
   * @example
   * const text = await vm.evalAsync(`
   *   require 'js'
   *   response = JS.global.fetch('https://example.com').await
   *   response.text.await
   * `);
   * console.log(text.toString()); // <html>...</html>
   */
  evalAsync(code: string): Promise<RbValue> {
    const JS = this.eval("require 'js'; JS");
    return newRbPromise(this, this.privateObject(), (future) => {
      JS.call("__eval_async_rb", this.wrap(code), future);
    });
  }

  /**
   * Wrap a JavaScript value into a Ruby JS::Object
   * @param value The value to convert to RbValue
   * @returns the RbValue object representing the given JS value
   *
   * @example
   * const hash = vm.eval(`Hash.new`)
   * hash.call("store", vm.eval(`"key1"`), vm.wrap(new Object()));
   */
  wrap(value: any): RbValue {
    return this.transport.importJsValue(value, this);
  }

  /** @private */
  private privateObject(): RubyVMPrivate {
    return {
      transport: this.transport,
      exceptionFormatter: this.exceptionFormatter,
    };
  }

  /** @private */
  private rbValueOfPointer(pointer: number): RbValue {
    const abiValue = new (RbAbi.RbAbiValue as any)(pointer, this.guest);
    return new RbValue(abiValue, this, this.privateObject());
  }
}

type RbAbiInterfaceState = {
  /**
   * Track if the last JS frame that was created by a Ruby frame
   * to determine if we have a sandwitched JS frame between Ruby frames.
   **/
  hasJSFrameAfterRbFrame: boolean;
};

/**
 * Export a JS value held by the Ruby VM to the JS environment.
 * This is implemented in a dirty way since wit cannot reference resources
 * defined in other interfaces.
 * In our case, we can't express `function(v: rb-abi-value) -> js-abi-value`
 * because `rb-js-abi-host.wit`, that defines `js-abi-value`, is implemented
 * by embedder side (JS) but `rb-abi-guest.wit`, that defines `rb-abi-value`
 * is implemented by guest side (Wasm).
 *
 * This class is a helper to export by:
 * 1. Call `function __export_to_js(v: rb-abi-value)` defined in guest from embedder side.
 * 2. Call `function takeJsValue(v: js-abi-value)` defined in embedder from guest side with
 *    underlying JS value of given `rb-abi-value`.
 * 3. Then `takeJsValue` implementation escapes the given JS value to the `_takenJsValues`
 *    stored in embedder side.
 * 4. Finally, embedder side can take `_takenJsValues`.
 *
 * Note that `exportJsValue` is not reentrant.
 *
 * @private
 */
class JsValueTransport {
  private _takenJsValue: JsAbiValue = null;
  takeJsValue(value: JsAbiValue) {
    this._takenJsValue = value;
  }
  consumeJsValue(): JsAbiValue {
    return this._takenJsValue;
  }

  exportJsValue(value: RbValue): JsAbiValue {
    value.call("__export_to_js");
    return this._takenJsValue;
  }

  importJsValue(value: JsAbiValue, vm: RubyVM): RbValue {
    this._takenJsValue = value;
    return vm.eval('require "js"; JS::Object').call("__import_from_js");
  }
}

/**
 * A RbValue is an object that represents a value in Ruby
 * @category Essentials
 */
export class RbValue {
  /**
   * @hideconstructor
   */
  constructor(
    private inner: RbAbiValue,
    private vm: RubyVM,
    private privateObject: RubyVMPrivate,
  ) {}

  /**
   * Call a given method with given arguments
   *
   * @param callee name of the Ruby method to call
   * @param args arguments to pass to the method. Must be an array of RbValue
   * @returns The result of the method call as a new RbValue.
   *
   * @example
   * const ary = vm.eval("[1, 2, 3]");
   * ary.call("push", 4);
   * console.log(ary.call("sample").toString());
   */
  call(callee: string, ...args: RbValue[]): RbValue {
    const innerArgs = args.map((arg) => arg.inner);
    return new RbValue(
      callRbMethod(this.vm, this.privateObject, this.inner, callee, innerArgs),
      this.vm,
      this.privateObject,
    );
  }

  /**
   * Call a given method that may call `JS::Object#await` with given arguments
   *
   * @param callee name of the Ruby method to call
   * @param args arguments to pass to the method. Must be an array of RbValue
   * @returns A Promise that resolves to the result of the method call as a new RbValue.
   *
   * @example
   * const client = vm.eval(`
   *   require 'js'
   *   class HttpClient
   *     def get(url)
   *       JS.global.fetch(url).await
   *     end
   *   end
   *   HttpClient.new
   * `);
   * const response = await client.callAsync("get", vm.eval(`"https://example.com"`));
   */
  callAsync(callee: string, ...args: RbValue[]): Promise<RbValue> {
    const JS = this.vm.eval("require 'js'; JS");
    return newRbPromise(this.vm, this.privateObject, (future) => {
      JS.call(
        "__call_async_method",
        this,
        this.vm.wrap(callee),
        future,
        ...args,
      );
    });
  }

  /**
   * @see {@link https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toPrimitive}
   * @param hint Preferred type of the result primitive value. `"number"`, `"string"`, or `"default"`.
   */
  [Symbol.toPrimitive](hint: string) {
    if (hint === "string" || hint === "default") {
      return this.toString();
    } else if (hint === "number") {
      return null;
    }
    return null;
  }

  /**
   * Returns a string representation of the value by calling `to_s`
   */
  toString(): string {
    const rbString = callRbMethod(
      this.vm,
      this.privateObject,
      this.inner,
      "to_s",
      [],
    );
    return this.vm.guest.rstringPtr(rbString);
  }

  /**
   * Returns a JavaScript object representation of the value
   * by calling `to_js`.
   *
   * Returns null if the value is not convertible to a JavaScript object.
   */
  toJS(): any {
    const JS = this.vm.eval("JS");
    const jsValue = JS.call("try_convert", this);
    if (jsValue.call("nil?").toString() === "true") {
      return null;
    }
    return this.privateObject.transport.exportJsValue(jsValue);
  }
}

enum ruby_tag_type {
  None = 0x0,
  Return = 0x1,
  Break = 0x2,
  Next = 0x3,
  Retry = 0x4,
  Redo = 0x5,
  Raise = 0x6,
  Throw = 0x7,
  Fatal = 0x8,
  Mask = 0xf,
}

type RubyVMPrivate = {
  transport: JsValueTransport;
  exceptionFormatter: RbExceptionFormatter;
};

class RbExceptionFormatter {
  private literalsCache: [RbValue, RbValue, RbValue] | null = null;
  private isFormmatting: boolean = false;

  format(error: RbValue, vm: RubyVM, privateObject: RubyVMPrivate): string {
    // All Ruby exceptions raised during formatting exception message should
    // be caught and return a fallback message.
    // Therefore, we don't need to worry about infinite recursion here ideally
    // but checking re-entrancy just in case.
    class RbExceptionFormatterError extends Error {}
    if (this.isFormmatting) {
      throw new RbExceptionFormatterError(
        "Unexpected exception occurred during formatting exception message",
      );
    }
    this.isFormmatting = true;
    try {
      return this._format(error, vm, privateObject);
    } finally {
      this.isFormmatting = false;
    }
  }

  private _format(
    error: RbValue,
    vm: RubyVM,
    privateObject: RubyVMPrivate,
  ): string {
    const [zeroLiteral, oneLiteral, newLineLiteral] = (() => {
      if (this.literalsCache == null) {
        const zeroOneNewLine: [RbValue, RbValue, RbValue] = [
          evalRbCode(vm, privateObject, "0"),
          evalRbCode(vm, privateObject, "1"),
          evalRbCode(vm, privateObject, `"\n"`),
        ];
        this.literalsCache = zeroOneNewLine;
        return zeroOneNewLine;
      } else {
        return this.literalsCache;
      }
    })();

    let className: string;
    let backtrace: RbValue;
    let message: string;
    try {
      className = error.call("class").toString();
    } catch (e) {
      className = "unknown";
    }

    try {
      message = error.call("message").toString();
    } catch (e) {
      message = "unknown";
    }

    try {
      backtrace = error.call("backtrace");
    } catch (e) {
      return this.formatString(className, message);
    }

    if (backtrace.call("nil?").toString() === "true") {
      return this.formatString(className, message);
    }
    try {
      const firstLine = backtrace.call("at", zeroLiteral);
      const restLines = backtrace
        .call("drop", oneLiteral)
        .call("join", newLineLiteral);
      return this.formatString(className, message, [
        firstLine.toString(),
        restLines.toString(),
      ]);
    } catch (e) {
      return this.formatString(className, message);
    }
  }

  formatString(
    klass: string,
    message: string,
    backtrace?: [string, string],
  ): string {
    if (backtrace) {
      return `${backtrace[0]}: ${message} (${klass})\n${backtrace[1]}`;
    } else {
      return `${klass}: ${message}`;
    }
  }
}

const checkStatusTag = (
  rawTag: number,
  vm: RubyVM,
  privateObject: RubyVMPrivate,
) => {
  switch (rawTag & ruby_tag_type.Mask) {
    case ruby_tag_type.None:
      break;
    case ruby_tag_type.Return:
      throw new RbError("unexpected return");
    case ruby_tag_type.Next:
      throw new RbError("unexpected next");
    case ruby_tag_type.Break:
      throw new RbError("unexpected break");
    case ruby_tag_type.Redo:
      throw new RbError("unexpected redo");
    case ruby_tag_type.Retry:
      throw new RbError("retry outside of rescue clause");
    case ruby_tag_type.Throw:
      throw new RbError("unexpected throw");
    case ruby_tag_type.Raise:
    case ruby_tag_type.Fatal:
      const error = new RbValue(vm.guest.rbErrinfo(), vm, privateObject);
      if (error.call("nil?").toString() === "true") {
        throw new RbError("no exception object");
      }
      // clear errinfo if got exception due to no rb_jump_tag
      vm.guest.rbClearErrinfo();
      throw new RbError(
        privateObject.exceptionFormatter.format(error, vm, privateObject),
      );
    default:
      throw new RbError(`unknown error tag: ${rawTag}`);
  }
};

function wrapRbOperation<R>(vm: RubyVM, body: () => R): R {
  try {
    return body();
  } catch (e) {
    if (e instanceof RbError) {
      throw e;
    }
    // All JS exceptions triggered by Ruby code are translated to Ruby exceptions,
    // so non-RbError exceptions are unexpected.
    try {
      vm.guest.rbVmBugreport();
    } catch (e) {
      console.error("Tried to report internal Ruby VM state but failed: ", e);
    }
    if (e instanceof WebAssembly.RuntimeError && e.message === "unreachable") {
      const error = new RbError(`Something went wrong in Ruby VM: ${e}`);
      error.stack = e.stack;
      throw error;
    } else {
      throw e;
    }
  }
}

const callRbMethod = (
  vm: RubyVM,
  privateObject: RubyVMPrivate,
  recv: RbAbiValue,
  callee: string,
  args: RbAbiValue[],
) => {
  const mid = vm.guest.rbIntern(callee + "\0");
  return wrapRbOperation(vm, () => {
    const [value, status] = vm.guest.rbFuncallvProtect(recv, mid, args);
    checkStatusTag(status, vm, privateObject);
    return value;
  });
};
const evalRbCode = (vm: RubyVM, privateObject: RubyVMPrivate, code: string) => {
  return wrapRbOperation(vm, () => {
    const [value, status] = vm.guest.rbEvalStringProtect(code + "\0");
    checkStatusTag(status, vm, privateObject);
    return new RbValue(value, vm, privateObject);
  });
};

function newRbPromise(
  vm: RubyVM,
  privateObject: RubyVMPrivate,
  body: (future: RbValue) => void,
): Promise<RbValue> {
  return new Promise((resolve, reject) => {
    const future = vm.wrap({
      resolve,
      reject: (error: RbValue) => {
        const rbError = new RbError(
          privateObject.exceptionFormatter.format(error, vm, privateObject),
        );
        reject(rbError);
      },
    });
    body(future);
  });
}

/**
 * Error class thrown by Ruby execution
 */
export class RbError extends Error {
  /**
   * @hideconstructor
   */
  constructor(message: string) {
    super(message);
  }
}

/**
 * Error class thrown by Ruby execution when it is not possible to recover.
 * This is usually caused when Ruby VM is in an inconsistent state.
 */
export class RbFatalError extends RbError {
  /**
   * @hideconstructor
   */
  constructor(message: string) {
    super("Ruby Fatal Error: " + message);
  }
}
